SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
15.5 KB · 241 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { notFound } from "next/navigation";4import { EventRow } from "@/components/event-row";5import { Badge, Chip, Empty, Flag, PageHeader, Panel, Score, Sparkline, StateBadge, Stat, TypeChip } from "@/components/ui";6import { ShareButton } from "@/components/watch-button";7import { api, SITE_URL, type ClusterDetail, type EventItem } from "@/lib/api";8import { fmtInt, fmtOffset, fmtScore, relTime, typeLabel, utcDate, utcDateTime, utcTime } from "@/lib/format";910export const dynamic = "force-dynamic";1112function num(v: number | string | null | undefined): number {13  if (v === null || v === undefined) return 0;14  const n = typeof v === "string" ? Number(v) : v;15  return Number.isFinite(n) ? n : 0;16}1718export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {19  const { slug } = await params;20  const d = await api.cluster(slug);21  if (!d) return { title: "Cluster not found" };22  const c = d.cluster;23  const desc = (c.summary?.trim() || `${c.event_count} signal${c.event_count === 1 ? "" : "s"} from ${c.source_count ?? 1} source${(c.source_count ?? 1) === 1 ? "" : "s"} — ${d.first_party_signals} first-party, ${d.external_signals} external. Propagation timeline with WebSensor lead time.`).slice(0, 200);24  const path = `/cluster/${c.slug ?? c.id}`;25  return {26    title: c.title,27    description: desc,28    alternates: { canonical: path },29    openGraph: { type: "article", title: c.title, description: desc, url: `${SITE_URL}${path}`, publishedTime: new Date(c.first_at).toISOString(), modifiedTime: new Date(c.last_at).toISOString(), tags: c.categories },30    twitter: { card: "summary", title: c.title, description: desc },31  };32}3334/** Signals per hour (or per day when the story spans more than 3 days) — computed from the events. */35function series(events: EventItem[], first: number, last: number): { values: number[]; unit: "hour" | "day" } {36  const span = Math.max(0, last - first);37  const unit: "hour" | "day" = span > 72 * 3600e3 ? "day" : "hour";38  const step = unit === "hour" ? 3600e3 : 86400e3;39  const start = Math.floor(first / step) * step;40  const n = Math.min(120, Math.floor((last - start) / step) + 1);41  const values = new Array<number>(Math.max(1, n)).fill(0);42  for (const e of events) {43    const i = Math.floor((new Date(e.detected_at).getTime() - start) / step);44    if (i >= 0 && i < values.length) values[i]! += 1;45  }46  return { values, unit };47}4849export default async function ClusterPage({ params }: { params: Promise<{ slug: string }> }) {50  const { slug } = await params;51  const d = await api.cluster(slug);52  if (!d) notFound();53  const c = d.cluster;54  const path = `/cluster/${c.slug ?? c.id}`;55  const lead = num(d.lead_time_ms ?? c.lead_time_ms);56  const velocity = num(c.velocity);57  const firstMs = new Date(c.first_at).getTime();58  const lastMs = new Date(c.last_at).getTime();59  const spanMs = Math.max(0, lastMs - firstMs);60  const sources = sourcesOf(d);61  const countryOf = (p: ClusterDetail["propagation"][number]): string | null => (p.source as { country?: string | null }).country ?? null;62  const spark = series(d.events, firstMs, lastMs);63  const primary = d.events.find((e) => e.id === c.primary_event_id) ?? d.events[0];64  const jsonLd = {65    "@context": "https://schema.org",66    "@type": "NewsArticle",67    headline: c.title,68    description: c.summary ?? undefined,69    datePublished: new Date(c.first_at).toISOString(),70    dateModified: new Date(c.last_at).toISOString(),71    url: `${SITE_URL}${path}`,72    mainEntityOfPage: `${SITE_URL}${path}`,73    author: { "@type": "Organization", name: "WebSensor", url: SITE_URL },74    publisher: { "@type": "Organization", name: "WebSensor", url: SITE_URL },75    about: d.entities.slice(0, 12).map((x) => ({ "@type": "Thing", name: x.name, url: `${SITE_URL}/entity/${x.id}` })),76    keywords: c.categories.join(", "),77    ...(primary?.url ? { isBasedOn: primary.url } : {}),78  };7980  return (81    <>82      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />83      <PageHeader84        compact85        kicker={86          <span className="flex flex-wrap items-center gap-x-2 gap-y-1">87            <StateBadge state={c.state} />88            <span className="font-mono text-fg-subtle">CLUSTER</span>89            {d.entities.slice(0, 6).map((x) => (90              <Chip key={x.id} href={`/entity/${x.id}`} title={typeLabel(x.type)}>{x.name}</Chip>91            ))}92            {d.entities.length > 6 && <span className="font-mono text-fg-subtle tabular">+{d.entities.length - 6}</span>}93            {c.categories.slice(0, 3).map((k) => <Chip key={k} href={`/category/${k}`} className="!text-fg-subtle">{k}</Chip>)}94          </span>95        }96        title={c.title}97        description={c.summary?.trim() ? <span className="whitespace-pre-line">{c.summary.trim()}</span> : undefined}98        actions={99          <>100            <ShareButton path={path} />101            {primary && <Link href={`/event/${primary.slug}`} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[12px] hover:border-line-strong">Primary event →</Link>}102          </>103        }104      />105106      <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-fg-muted">107        <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.event_count)}</span> signal{c.event_count === 1 ? "" : "s"}</span>108        <span className="text-fg-subtle">·</span>109        <span><span className="font-mono font-semibold text-signal tabular">{fmtInt(d.first_party_signals)}</span> first-party</span>110        <span className="text-fg-subtle">·</span>111        <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(d.external_signals)}</span> external</span>112        <span className="text-fg-subtle">·</span>113        <span><span className="font-mono font-semibold text-fg tabular">{fmtInt(c.source_count ?? sources.length)}</span> source{(c.source_count ?? sources.length) === 1 ? "" : "s"}</span>114        <span className="text-fg-subtle">·</span>115        <span className="font-mono text-[11.5px] text-fg-subtle tabular" title={`${utcDateTime(c.first_at)} → ${utcDateTime(c.last_at)}`}>{utcDateTime(c.first_at).replace(" UTC", "")} → {utcDate(c.last_at) === utcDate(c.first_at) ? utcTime(c.last_at) : utcDateTime(c.last_at).replace(" UTC", "")} UTC</span>116        <span className="text-fg-subtle">·</span>117        <span className="text-fg-subtle">updated {relTime(c.last_at)}</span>118      </div>119120      {lead > 0 && (121        <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 border-l-2 border-l-signal bg-signal-soft/50 px-3 py-2 text-[12.5px]" role="note">122          <span className="font-mono text-[10.5px] font-semibold tracking-wider text-signal">WEBSENSOR LEAD TIME</span>123          <span className="text-fg">124            detected <span className="font-mono font-semibold text-signal tabular">{fmtOffset(lead).replace("+", "")}</span> before the first external report125          </span>126          {c.first_party_at && c.first_external_at && (127            <span className="font-mono text-[11px] text-fg-subtle tabular">first-party {utcTime(c.first_party_at, false)} · external {utcTime(c.first_external_at, false)} UTC</span>128          )}129        </div>130      )}131132      <div className="mb-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-line bg-line sm:grid-cols-4">133        <div className="bg-panel"><Stat label="Peak signal" value={fmtScore(Math.max(c.max_importance, ...d.events.map((e) => e.signal_score ?? 0)))} hint={`importance ${fmtScore(c.max_importance)}`} tone={c.max_importance >= 80 ? "hot" : undefined} /></div>134        <div className="bg-panel"><Stat label="Velocity" value={velocity > 0 ? fmtScore(velocity) : "0"} hint={velocity >= 40 ? "signals accumulating fast" : "signals per unit of time"} tone={velocity >= 40 ? "warn" : undefined} /></div>135        <div className="bg-panel"><Stat label="Span" value={fmtOffset(spanMs).replace("+", "")} hint={`${utcTime(c.first_at, false)} → ${utcTime(c.last_at, false)} UTC`} /></div>136        <div className="bg-panel"><Stat label="Lead time" value={lead > 0 ? fmtOffset(lead).replace("+", "") : "—"} hint={lead > 0 ? "first-party before external" : d.external_signals === 0 ? "no external report yet" : lead < 0 ? `external first by ${fmtOffset(-lead).replace("+", "")}` : "n/a"} tone={lead > 0 ? "signal" : undefined} /></div>137      </div>138139      <div className="grid gap-4 xl:grid-cols-[1fr_340px]">140        <div className="flex min-w-0 flex-col gap-4">141          <Panel title={<>Web velocity · propagation <span className="font-mono text-fg-subtle">{d.propagation.length}</span></>} dense action={<span className="hidden items-center gap-3 font-mono text-[10.5px] text-fg-subtle sm:inline-flex"><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-signal" /> first-party</span><span className="inline-flex items-center gap-1"><span className="inline-block h-3 w-0.5 bg-line-strong" /> external</span></span>}>142            {d.propagation.length === 0 ? (143              <Empty>No propagation recorded yet — the cluster has a single signal.</Empty>144            ) : (145              <ol className="divide-y divide-line">146                {d.propagation.map((p, i) => (147                  <li key={p.id} className={`grid grid-cols-[4.25rem_1fr] gap-x-3 border-l-2 px-3 py-2 sm:grid-cols-[4.25rem_1fr_auto] ${p.first_party ? "border-l-signal" : "border-l-line-strong"}`}>148                    <div className="flex flex-col font-mono text-[12px] leading-4 tabular">149                      <span className={`font-semibold ${i === 0 ? "text-fg" : p.first_party ? "text-signal" : "text-fg-muted"}`}>{i === 0 || p.offset_ms === 0 ? "00:00" : fmtOffset(p.offset_ms)}</span>150                      <time dateTime={p.at} title={utcDateTime(p.at)} className="text-[10.5px] text-fg-subtle">{utcTime(p.at)}</time>151                    </div>152                    <div className="min-w-0 sm:col-start-2">153                      <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">154                        <Link href={`/source/${p.source.id}`} className="font-mono font-semibold uppercase tracking-wide text-fg-muted hover:text-fg">{p.source.name}</Link>155                        {countryOf(p) && <Flag code={countryOf(p)} />}156                        <span className="sm:hidden"><Score value={p.importance} size="sm" /></span>157                        <Badge kind={p.first_party ? "first-party" : "external"} compact />158                        {p.sensor?.type && <Chip className="font-mono" title={p.sensor.name}>{p.sensor.type}</Chip>}159                        <TypeChip type={p.event_type} />160                      </div>161                      <Link href={`/event/${p.slug}`} className="mt-0.5 block font-medium leading-snug text-fg hover:underline">{p.title}</Link>162                    </div>163                    <div className="hidden items-start pt-0.5 sm:flex"><Score value={p.importance} /></div>164                  </li>165                ))}166              </ol>167            )}168          </Panel>169          <Panel title={<>Signals <span className="font-mono text-fg-subtle">{d.events.length}</span></>} dense action={primary && <Link href={`/event/${primary.slug}`} className="text-[11px] text-fg-subtle hover:text-fg">primary event →</Link>}>170            {d.events.length ? d.events.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No signal in this cluster.</Empty>}171          </Panel>172        </div>173        <aside className="flex min-w-0 flex-col gap-4">174          <Panel title={spark.unit === "hour" ? "Signals per hour" : "Signals per day"}>175            <Sparkline values={spark.values} width={300} height={44} tone={c.state === "breaking" ? "hot" : "signal"} />176            <div className="mt-1 flex justify-between font-mono text-[10.5px] text-fg-subtle tabular">177              <span>{utcDate(c.first_at)} {utcTime(c.first_at, false)}</span>178              <span>peak {Math.max(0, ...spark.values)}/{spark.unit === "hour" ? "h" : "d"}</span>179              <span>{utcDate(c.last_at)} {utcTime(c.last_at, false)}</span>180            </div>181          </Panel>182          <Panel title={<>Entities <span className="font-mono text-fg-subtle">{d.entities.length}</span></>} dense>183            {d.entities.length === 0 ? (184              <Empty>No entity resolved for this cluster.</Empty>185            ) : (186              <ul className="divide-y divide-line">187                {d.entities.slice(0, 14).map((x) => (188                  <li key={x.id} className="flex items-center justify-between gap-2 px-3 py-1.5 text-[12.5px]">189                    <Link href={`/entity/${x.id}`} className="min-w-0 truncate font-medium hover:underline">{x.name}</Link>190                    <span className="flex shrink-0 items-center gap-1.5"><Chip>{typeLabel(x.type)}</Chip><Score value={x.importance} size="sm" /></span>191                  </li>192                ))}193                {d.entities.length > 14 && <li className="px-3 py-1.5 text-[11px] text-fg-subtle">+{d.entities.length - 14} more entities</li>}194              </ul>195            )}196          </Panel>197          <Panel title={<>Sources <span className="font-mono text-fg-subtle">{sources.length}</span></>} dense>198            {sources.length === 0 ? (199              <Empty>No source.</Empty>200            ) : (201              <ul className="divide-y divide-line">202                {sources.map((s) => (203                  <li key={s.id} className="grid grid-cols-[1fr_auto_2rem] items-center gap-x-2 px-3 py-1.5 text-[12.5px]">204                    <div className="min-w-0">205                      <Link href={`/source/${s.id}`} className="block truncate font-medium hover:underline">{s.name}</Link>206                      <div className="flex items-center gap-1.5 font-mono text-[10.5px] text-fg-subtle"><span className="truncate">{s.domain}</span>{s.country && <Flag code={s.country} />}</div>207                    </div>208                    <Badge kind={s.first_party ? "first-party" : "external"} compact />209                    <span className="text-right font-mono text-[12px] tabular text-fg-muted" title="Signals from this source">{s.n}</span>210                  </li>211                ))}212              </ul>213            )}214          </Panel>215          <Panel title="Share">216            <div className="flex flex-wrap items-center gap-2">217              <ShareButton path={path} />218              <Link href={`/api/v1/clusters/${encodeURIComponent(c.slug ?? c.id)}`} className="rounded-md border border-line bg-panel px-2.5 py-1 font-mono text-[11.5px] hover:border-line-strong">JSON</Link>219            </div>220            <p className="mt-2 break-all font-mono text-[10.5px] text-fg-subtle">{SITE_URL}{path}</p>221          </Panel>222        </aside>223      </div>224    </>225  );226}227228function sourcesOf(d: ClusterDetail): { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }[] {229  const m = new Map<string, { id: string; name: string; domain: string; first_party: boolean; country: string | null; n: number }>();230  for (const e of d.events) {231    const id = e.source?.id ?? e.source_id;232    const cur = m.get(id);233    if (cur) cur.n += 1;234    else m.set(id, { id, name: e.source?.name ?? id, domain: e.source?.domain ?? "", first_party: e.first_party !== false, country: e.country ?? null, n: 1 });235  }236  for (const p of d.propagation) {237    if (!m.has(p.source.id)) m.set(p.source.id, { id: p.source.id, name: p.source.name, domain: p.source.domain, first_party: p.first_party, country: (p.source as { country?: string | null }).country ?? null, n: 1 });238  }239  return [...m.values()].sort((a, b) => Number(b.first_party) - Number(a.first_party) || b.n - a.n);240}241